Merge Noise peer binding dependency

This commit is contained in:
jack
2026-07-12 10:15:01 -04:00
45 changed files with 3438 additions and 175 deletions
@@ -7,10 +7,9 @@
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: the async media-preparation pipelines (`ImageUtils`,
// `ChatMediaPreparation`) run real file/codec work and remain covered by
// `ChatMediaPreparationTests`; here we cover message enqueueing, transfer
// bookkeeping, and the blocked-context guards.
// Real file/codec work remains covered by `ChatMediaPreparationTests`. These
// tests inject a paused voice-note preparer to exercise cancellation ownership
// across the detached-preparation/MainActor boundary deterministically.
//
import Testing
@@ -86,11 +85,64 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
// Mesh file transfer
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
private(set) var privateFileLegacyAllowances: [Bool] = []
private(set) var broadcastFileSends: [String] = []
private(set) var cancelledTransfers: [String] = []
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
private(set) var legacyConsentRequests: [(
id: UUID,
peerID: PeerID,
transferId: String,
messageID: String
)] = []
private(set) var invalidatedLegacyConsents: [(transferId: String, messageID: String)] = []
private var pendingLegacyConsentIDs: [UUID] = []
private var legacyConsentCompletions: [UUID: @MainActor (Bool) -> Void] = [:]
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
privateMediaPolicy
}
func requestLegacyPrivateMediaConsent(
for peerID: PeerID,
transferId: String,
messageID: String,
completion: @escaping @MainActor (Bool) -> Void
) {
let id = UUID()
legacyConsentRequests.append((id, peerID, transferId, messageID))
pendingLegacyConsentIDs.append(id)
legacyConsentCompletions[id] = completion
}
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) {
invalidatedLegacyConsents.append((transferId, messageID))
let matchingIDs = Set(legacyConsentRequests.compactMap { request in
request.transferId == transferId && request.messageID == messageID
? request.id
: nil
})
pendingLegacyConsentIDs.removeAll { matchingIDs.contains($0) }
}
func resolveNextLegacyConsent(_ approved: Bool) {
guard !pendingLegacyConsentIDs.isEmpty else { return }
let id = pendingLegacyConsentIDs.removeFirst()
legacyConsentCompletions[id]?(approved)
}
func invokeLegacyConsentEvenIfInvalidated(id: UUID, approved: Bool) {
legacyConsentCompletions[id]?(approved)
}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
privateFileSends.append((peerID, transferId))
privateFileLegacyAllowances.append(allowLegacyFallback)
}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
@@ -102,6 +154,56 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
}
}
private final class PausedVoiceNotePreparer: @unchecked Sendable {
private let condition = NSCondition()
private var started = false
private var released = false
private var finished = false
private let packet: BitchatFilePacket
init() {
let content = Data("voice".utf8)
packet = BitchatFilePacket(
fileName: "paused.m4a",
fileSize: UInt64(content.count),
mimeType: "audio/mp4",
content: content
)
}
func prepare(_: URL) throws -> BitchatFilePacket {
condition.lock()
started = true
condition.broadcast()
while !released {
condition.wait()
}
finished = true
condition.broadcast()
condition.unlock()
return packet
}
var hasStarted: Bool {
condition.lock()
defer { condition.unlock() }
return started
}
var hasFinished: Bool {
condition.lock()
defer { condition.unlock() }
return finished
}
func release() {
condition.lock()
released = true
condition.broadcast()
condition.unlock()
}
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatMediaTransferCoordinator` against
@@ -166,6 +268,14 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(context.removedMessages.count == 1)
#expect(context.removedMessages.first?.messageID == "m2")
#expect(context.removedMessages.first?.cleanupFile == true)
// A pre-start rejection keeps the placeholder visible and failed,
// including queued post-handshake encryption failures.
coordinator.registerTransfer(transferId: "t3", messageID: "m3")
coordinator.handleTransferEvent(.rejected(id: "t3", reason: "encryption failed"))
#expect(context.deliveryStatusUpdates.last?.messageID == "m3")
#expect(context.deliveryStatusUpdates.last?.status == .failed(reason: "encryption failed"))
#expect(coordinator.messageIDToTransferId["m3"] == nil)
}
@Test @MainActor
@@ -188,6 +298,20 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(coordinator.messageIDToTransferId.isEmpty)
}
@Test @MainActor
func deleteMediaMessage_cancelsApprovedTransferBeforeRemovingMapping() {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
coordinator.registerTransfer(transferId: "approved-delete", messageID: "message-delete")
coordinator.deleteMediaMessage(messageID: "message-delete")
#expect(context.cancelledTransfers == ["approved-delete"])
#expect(coordinator.messageIDToTransferId["message-delete"] == nil)
#expect(context.removedMessages.map(\.messageID) == ["message-delete"])
#expect(context.removedMessages.first?.cleanupFile == true)
}
@Test @MainActor
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
let context = MockChatMediaTransferContext()
@@ -206,4 +330,224 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(context.appendedPublicMessages.isEmpty)
#expect(coordinator.transferIdToMessageIDs.isEmpty)
}
@Test @MainActor
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "5566778899aabbcc")
context.selectedPrivateChatPeer = peerID
let preparer = PausedVoiceNotePreparer()
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in try preparer.prepare(url) }
)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("paused-private-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer {
preparer.release()
try? FileManager.default.removeItem(at: url)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout))
let messageID = try #require(context.privateChats[peerID]?.first?.id)
let transferId = try #require(coordinator.messageIDToTransferId[messageID])
coordinator.cancelMediaSend(messageID: messageID)
preparer.release()
#expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout))
for _ in 0..<10 { await Task.yield() }
#expect(context.cancelledTransfers == [transferId])
#expect(context.privateFileSends.isEmpty)
#expect(context.broadcastFileSends.isEmpty)
#expect(coordinator.messageIDToTransferId[messageID] == nil)
#expect(coordinator.transferIdToMessageIDs[transferId] == nil)
#expect(context.removedMessages.map(\.messageID) == [messageID])
}
@Test @MainActor
func deletePublicVoiceNoteDuringDetachedPreparationCannotBroadcastOrRestoreMapping() async throws {
let context = MockChatMediaTransferContext()
let preparer = PausedVoiceNotePreparer()
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in try preparer.prepare(url) }
)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("paused-public-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer {
preparer.release()
try? FileManager.default.removeItem(at: url)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout))
let messageID = try #require(context.appendedPublicMessages.first?.message.id)
let transferId = try #require(coordinator.messageIDToTransferId[messageID])
coordinator.deleteMediaMessage(messageID: messageID)
preparer.release()
#expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout))
for _ in 0..<10 { await Task.yield() }
#expect(context.cancelledTransfers == [transferId])
#expect(context.broadcastFileSends.isEmpty)
#expect(context.privateFileSends.isEmpty)
#expect(coordinator.messageIDToTransferId[messageID] == nil)
#expect(coordinator.transferIdToMessageIDs[transferId] == nil)
#expect(context.removedMessages.map(\.messageID) == [messageID])
}
@Test @MainActor
func voicePreparationFailureMarksPlaceholderFailedAndClearsEarlyMapping() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "66778899aabbccdd")
context.selectedPrivateChatPeer = peerID
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { _ in
throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: 999_999)
}
)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("failing-private-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{
context.deliveryStatusUpdates.contains { update in
if case .failed = update.status { return true }
return false
}
},
timeout: TestConstants.longTimeout
))
let messageID = try #require(context.privateChats[peerID]?.first?.id)
#expect(coordinator.messageIDToTransferId[messageID] == nil)
#expect(coordinator.transferIdToMessageIDs.isEmpty)
#expect(context.privateFileSends.isEmpty)
#expect(context.broadcastFileSends.isEmpty)
}
@Test @MainActor
func legacyPrivateVoiceNoteWaitsForPerSendConsent() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .legacyRequiresConsent
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("legacy-consent-\(UUID().uuidString).m4a")
try (Data([0x00, 0x00, 0x00, 0x18]) + Data("ftypM4A voice".utf8)).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
let prompted = await TestHelpers.waitUntil(
{ context.legacyConsentRequests.count == 1 },
timeout: TestConstants.longTimeout
)
#expect(prompted)
#expect(context.legacyConsentRequests.map { $0.peerID } == [peerID])
#expect(context.privateFileSends.isEmpty)
context.resolveNextLegacyConsent(true)
#expect(context.privateFileSends.count == 1)
#expect(context.privateFileLegacyAllowances == [true])
}
@Test @MainActor
func legacyConsentApprovalAfterCancelCannotSend() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "2233445566778899")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .legacyRequiresConsent
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("legacy-cancel-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
let prompted = await TestHelpers.waitUntil(
{ context.legacyConsentRequests.count == 1 },
timeout: TestConstants.longTimeout
)
#expect(prompted)
let request = try #require(context.legacyConsentRequests.first)
coordinator.cancelMediaSend(messageID: request.messageID)
#expect(context.invalidatedLegacyConsents.contains {
$0.transferId == request.transferId && $0.messageID == request.messageID
})
// Model a stale framework callback that escaped active invalidation.
// The coordinator's transfer/message binding check is the final gate.
context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true)
#expect(context.privateFileSends.isEmpty)
#expect(coordinator.messageIDToTransferId[request.messageID] == nil)
}
@Test @MainActor
func legacyConsentApprovalAfterDeleteCannotSend() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "33445566778899aa")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .legacyRequiresConsent
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("legacy-delete-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
let prompted = await TestHelpers.waitUntil(
{ context.legacyConsentRequests.count == 1 },
timeout: TestConstants.longTimeout
)
#expect(prompted)
let request = try #require(context.legacyConsentRequests.first)
coordinator.deleteMediaMessage(messageID: request.messageID)
context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true)
#expect(context.invalidatedLegacyConsents.contains {
$0.transferId == request.transferId && $0.messageID == request.messageID
})
#expect(context.privateFileSends.isEmpty)
#expect(coordinator.messageIDToTransferId[request.messageID] == nil)
}
@Test @MainActor
func pinnedPrivateMediaDowngradeNeverPromptsOrSends() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .blockedDowngrade
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("blocked-downgrade-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
let failed = await TestHelpers.waitUntil(
{ context.deliveryStatusUpdates.contains { update in
if case .failed = update.status { return true }
return false
} },
timeout: TestConstants.longTimeout
)
#expect(failed)
#expect(context.legacyConsentRequests.isEmpty)
#expect(context.privateFileSends.isEmpty)
}
}
@@ -1048,6 +1048,89 @@ struct ChatViewModelMediaTransferTests {
#expect(viewModel.transferIdToMessageIDs.count == 1)
}
@Test @MainActor
func legacyPrivateMediaConsentRequestsArePerSendAndQueued() async throws {
let (viewModel, _) = makeTestableViewModel()
let firstPeer = PeerID(str: "1111111111111111")
let secondPeer = PeerID(str: "2222222222222222")
var decisions: [Bool] = []
viewModel.enqueueLegacyPrivateMediaConsent(
for: firstPeer,
transferId: "transfer-1",
messageID: "message-1"
) { decisions.append($0) }
viewModel.enqueueLegacyPrivateMediaConsent(
for: secondPeer,
transferId: "transfer-2",
messageID: "message-2"
) { decisions.append($0) }
#expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == firstPeer)
let firstRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: true)
let showedSecond = await TestHelpers.waitUntil(
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
timeout: TestConstants.longTimeout
)
#expect(showedSecond)
let secondRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
// A button action and the dialog binding may both resolve the first
// ID. The stale second callback must not consume the queued request.
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: false)
#expect(decisions == [true])
#expect(viewModel.legacyPrivateMediaConsentRequest?.id == secondRequestID)
viewModel.resolveLegacyPrivateMediaConsent(requestID: secondRequestID, approved: false)
#expect(decisions == [true, false])
#expect(viewModel.legacyPrivateMediaConsentRequest == nil)
}
@Test @MainActor
func invalidatingPresentedLegacyConsentAdvancesQueueAndStaleResolutionNoops() async throws {
let (viewModel, _) = makeTestableViewModel()
let firstPeer = PeerID(str: "3333333333333333")
let secondPeer = PeerID(str: "4444444444444444")
var decisions: [String] = []
viewModel.enqueueLegacyPrivateMediaConsent(
for: firstPeer,
transferId: "transfer-cancelled",
messageID: "message-cancelled"
) { decisions.append("first:\($0)") }
viewModel.enqueueLegacyPrivateMediaConsent(
for: secondPeer,
transferId: "transfer-kept",
messageID: "message-kept"
) { decisions.append("second:\($0)") }
let cancelledRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
viewModel.invalidateLegacyPrivateMediaConsent(
transferId: "transfer-cancelled",
messageID: "message-cancelled"
)
let advanced = await TestHelpers.waitUntil(
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
timeout: TestConstants.longTimeout
)
#expect(advanced)
#expect(decisions.isEmpty, "Invalidation drops the request rather than resolving its send")
viewModel.resolveLegacyPrivateMediaConsent(
requestID: cancelledRequestID,
approved: true
)
#expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer)
#expect(decisions.isEmpty)
let keptRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
viewModel.resolveLegacyPrivateMediaConsent(requestID: keptRequestID, approved: true)
#expect(decisions == ["second:true"])
#expect(viewModel.legacyPrivateMediaConsentRequest == nil)
}
@Test @MainActor
func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {
let (viewModel, transport) = makeTestableViewModel()
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -14,6 +14,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
private var blockedFingerprints: Set<String> = []
private var blockedNostrPubkeys: Set<String> = []
private var socialIdentities: [String: SocialIdentity] = [:]
private var privateMediaCapableFingerprints: Set<String> = []
init(_: KeychainManagerProtocol) {}
@@ -87,7 +88,9 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
func clearAllIdentityData() {}
func clearAllIdentityData() {
privateMediaCapableFingerprints.removeAll()
}
func removeEphemeralSession(peerID: PeerID) {}
@@ -101,6 +104,14 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
Set()
}
func markPrivateMediaCapable(fingerprint: String) {
privateMediaCapableFingerprints.insert(fingerprint)
}
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
privateMediaCapableFingerprints.contains(fingerprint)
}
// MARK: Vouching (transitive verification)
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
+17
View File
@@ -36,6 +36,7 @@ final class MockTransport: Transport {
private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = []
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
private(set) var sentPrivateFileLegacyAllowances: [Bool] = []
private(set) var cancelledTransfers: [String] = []
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
@@ -58,6 +59,7 @@ final class MockTransport: Transport {
var peerNicknames: [PeerID: String] = [:]
var peerFingerprints: [PeerID: String] = [:]
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
private let mockKeychain = MockKeychain()
// MARK: - Transport Protocol Implementation
@@ -186,6 +188,21 @@ final class MockTransport: Transport {
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
sentPrivateFiles.append((packet, peerID, transferId))
sentPrivateFileLegacyAllowances.append(false)
}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
sentPrivateFiles.append((packet, peerID, transferId))
sentPrivateFileLegacyAllowances.append(allowLegacyFallback)
}
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
privateMediaPolicies[peerID] ?? .encrypted
}
func cancelTransfer(_ transferId: String) {
@@ -33,6 +33,7 @@ struct BLEFileTransferHandlerTests {
recorder.signatureVerifyCount += 1
return recorder.signatureVerifies
},
localSigningPublicKey: { [sampleSigningKey] in sampleSigningKey },
signedSenderDisplayName: { _, peerID in
recorder.signedNameQueries.append(peerID)
return recorder.signedName
@@ -92,12 +93,11 @@ struct BLEFileTransferHandlerTests {
@Test
func selfEchoIsDropped() throws {
let recorder = Recorder()
recorder.signatureVerifies = true
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3)
// The relay pipeline already suppresses self-originated packets, so the
// handler reports "relayable" rather than treating the echo as forged.
#expect(handler.handle(packet, from: localPeerID))
#expect(!handler.handle(packet, from: localPeerID))
expectNoSideEffects(recorder)
}
@@ -120,7 +120,12 @@ struct BLEFileTransferHandlerTests {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
let packet = try makeFileTransferPacket(
sender: remotePeerID,
mimeType: "application/pdf",
content: Data("%PDF-1.7".utf8),
hasSignature: false
)
// Failed sender authentication must also stop the packet from being
// relayed to downstream nodes.
@@ -129,7 +134,7 @@ struct BLEFileTransferHandlerTests {
// Broadcast files carry an attacker-controllable senderID, so like
// public messages a connected-but-unverified peer must present a valid
// packet signature. No signing key + no signed identity means dropped.
#expect(recorder.signedNameQueries == [remotePeerID])
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
}
@@ -153,12 +158,11 @@ struct BLEFileTransferHandlerTests {
}
@Test
func selfBroadcastReplayIsDeliveredWithoutSignatureCheck() throws {
// Our own broadcast file replayed via gossip sync arrives with ttl==0
// (so it is not treated as a self-echo) and cannot be verified against
// the peer registry it must still be accepted, matching
// BLEPublicMessageHandler's self exemption.
func signedSelfBroadcastReplayIsDelivered() throws {
// Our own broadcast file replayed via gossip sync arrives with ttl==0;
// it is verified against our local signing key before delivery.
let recorder = Recorder()
recorder.signatureVerifies = true
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(
sender: localPeerID,
@@ -169,7 +173,7 @@ struct BLEFileTransferHandlerTests {
#expect(handler.handle(packet, from: localPeerID))
#expect(recorder.signatureVerifyCount == 0)
#expect(recorder.signatureVerifyCount == 1)
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.sender == "Me")
@@ -205,7 +209,8 @@ struct BLEFileTransferHandlerTests {
sender: remotePeerID,
mimeType: "audio/mp4",
content: m4a,
fileName: "voice_1122334455667788"
fileName: "voice_1122334455667788",
hasSignature: false
)
// The spoofed note must be dropped locally AND not relayed onward.
@@ -215,7 +220,7 @@ struct BLEFileTransferHandlerTests {
}
@Test
func privateFileFromConnectedUnverifiedPeerIsAccepted() throws {
func rawDirectedFileWithoutVerifiableSignatureIsDroppedWithoutWriteOrRelay() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
let handler = makeHandler(recorder: recorder)
@@ -223,23 +228,25 @@ struct BLEFileTransferHandlerTests {
sender: remotePeerID,
mimeType: "application/pdf",
content: Data("%PDF-1.7".utf8),
recipientID: Data(hexString: localPeerID.id)
recipientID: Data(hexString: localPeerID.id),
hasSignature: false
)
#expect(handler.handle(packet, from: remotePeerID))
#expect(!handler.handle(packet, from: remotePeerID))
// Directed transfers keep the lenient connected-peer path (no broadcast
// exposure); no signature check is required.
#expect(recorder.signatureVerifyCount == 0)
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.isPrivate == true)
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
}
@Test
func fileDirectedToAnotherPeerIsIgnored() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
recorder.signatureVerifies = true
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(
sender: remotePeerID,
@@ -260,7 +267,8 @@ struct BLEFileTransferHandlerTests {
@Test
func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
recorder.signatureVerifies = true
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(
sender: remotePeerID,
@@ -282,6 +290,56 @@ struct BLEFileTransferHandlerTests {
#expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900)))
}
@Test
func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let file = BitchatFilePacket(
fileName: "secret.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
let timestamp = Date(timeIntervalSince1970: 1_234)
#expect(handler.handlePrivatePayload(payload, from: remotePeerID, timestamp: timestamp))
#expect(recorder.signatureVerifyCount == 0)
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.quotaReservations == [content.count])
#expect(recorder.saveCalls.first?.data == content)
#expect(recorder.lastSeenUpdates == [remotePeerID])
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.isPrivate == true)
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
}
@Test
func decryptedPrivateFileOverPayloadCapIsRejectedBeforeQuotaOrDiskWrite() {
let recorder = Recorder()
let handler = makeHandler(recorder: recorder)
let oversizedCount = FileTransferLimits.maxPayloadBytes + 1
var length = UInt32(oversizedCount).bigEndian
var payload = Data([0x04]) // BitchatFilePacket CONTENT TLV
withUnsafeBytes(of: &length) { payload.append(contentsOf: $0) }
payload.append(Data(repeating: 0x41, count: oversizedCount))
#expect(!handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.lastSeenUpdates.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
}
@Test
func malformedPayloadIsTrackedForSyncButDropped() {
let recorder = Recorder()
@@ -294,7 +352,7 @@ struct BLEFileTransferHandlerTests {
recipientID: nil,
timestamp: 900_000,
payload: Data([0x01, 0x02, 0x03]),
signature: nil,
signature: Data(repeating: 0x5A, count: 64),
ttl: TransportConfig.messageTTLDefault
)
@@ -403,7 +461,8 @@ struct BLEFileTransferHandlerTests {
content: Data,
ttl: UInt8 = TransportConfig.messageTTLDefault,
recipientID: Data? = nil,
fileName: String = "sample"
fileName: String = "sample",
hasSignature: Bool = true
) throws -> BitchatPacket {
let filePacket = BitchatFilePacket(
fileName: fileName,
@@ -418,7 +477,7 @@ struct BLEFileTransferHandlerTests {
recipientID: recipientID,
timestamp: 900_000,
payload: payload,
signature: nil,
signature: hasSignature ? Data(repeating: 0x5A, count: 64) : nil,
ttl: ttl
)
}
@@ -117,6 +117,35 @@ struct BLEFragmentAssemblyBufferTests {
}
}
@Test
func encryptedPrivateFileAssemblyGetsFramedFileHeadroom() throws {
var buffer = BLEFragmentAssemblyBuffer()
let fragmentID = Data(repeating: 0x15, count: 8)
let first = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
fragmentID: fragmentID,
index: 0,
total: 2,
originalType: MessageType.noiseEncrypted.rawValue,
fragmentData: Data(repeating: 0x01, count: FileTransferLimits.maxPayloadBytes)
)))
let second = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
fragmentID: fragmentID,
index: 1,
total: 2,
originalType: MessageType.noiseEncrypted.rawValue,
fragmentData: Data([0x02])
)))
_ = buffer.append(first, maxInFlightAssemblies: 8)
let result = buffer.append(second, maxInFlightAssemblies: 8)
if case let .complete(_, data, _) = result {
#expect(data.count == FileTransferLimits.maxPayloadBytes + 1)
} else {
Issue.record("Expected encrypted private-file assembly to use framed-file limit")
}
}
@Test
func removeExpiredDropsOldAssemblies() throws {
var buffer = BLEFragmentAssemblyBuffer()
@@ -1,5 +1,6 @@
import Foundation
import Testing
import BitFoundation
@testable import bitchat
struct BLENoisePayloadFactoryTests {
@@ -31,4 +32,51 @@ struct BLENoisePayloadFactoryTests {
#expect(payload == Data([NoisePayloadType.verifyChallenge.rawValue, 0xCA, 0xFE]))
}
@Test
func privateFilePayloadPrefixesCanonicalFilePacket() throws {
let content = Data("%PDF-secret".utf8)
let file = BitchatFilePacket(
fileName: "secret.pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
)
let payload = try #require(BLENoisePayloadFactory.privateFile(file))
#expect(payload.first == 0x20, "Encrypted files must use Android's deployed wire value")
let decoded = try #require(BitchatFilePacket.decode(Data(payload.dropFirst())))
#expect(decoded.fileName == "secret.pdf")
#expect(decoded.mimeType == "application/pdf")
#expect(decoded.content == content)
}
@Test
func androidB7f0b33PrivateFilePlaintextFixtureIsByteCompatible() throws {
// Runtime-emitted by Android commit b7f0b33d from
// BitchatFilePacket("a.txt", 3, "text/plain", [01, 02, 03]) and
// NoisePayload(type = FILE_TRANSFER, data = file.encode()).encode().
let fixtureHex = "20010005612e7478740200040000000303000a746578742f706c61696e0400000003010203"
let fixture = try #require(Data(hexString: fixtureHex))
let typed = try #require(NoisePayload.decode(fixture))
#expect(typed.type == .privateFile)
let file = try #require(BitchatFilePacket.decode(typed.data))
#expect(file.fileName == "a.txt")
#expect(file.fileSize == 3)
#expect(file.mimeType == "text/plain")
#expect(file.content == Data([0x01, 0x02, 0x03]))
#expect(BLENoisePayloadFactory.privateFile(file) == fixture)
}
@Test
func prereleasePrivateFileTypeCanonicalizesOnDecode() throws {
let encoded = Data([NoisePayloadType.prereleasePrivateFileRawValue, 0xCA, 0xFE])
let decoded = try #require(NoisePayload.decode(encoded))
#expect(decoded.type == .privateFile)
#expect(decoded.data == Data([0xCA, 0xFE]))
#expect(decoded.encode().first == 0x20)
}
}
@@ -48,7 +48,10 @@ struct BLENoiseSessionQueuesTests {
queues.appendTypedPayload(Data([0x01]), for: peerID)
queues.appendTypedPayload(Data([0x02]), for: peerID)
#expect(queues.takeTypedPayloads(for: peerID) == [Data([0x01]), Data([0x02])])
#expect(queues.takeTypedPayloads(for: peerID) == [
BLEPendingTypedPayload(payload: Data([0x01]), transferId: nil),
BLEPendingTypedPayload(payload: Data([0x02]), transferId: nil)
])
#expect(queues.takeTypedPayloads(for: peerID).isEmpty)
#expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1"])
}
@@ -64,4 +67,21 @@ struct BLENoiseSessionQueuesTests {
#expect(queues.isEmpty)
}
@Test
func transferIDSurvivesHandshakeQueueAndCanBeCancelledBeforeDrain() {
let peerID = PeerID(str: "aaaaaaaaaaaaaaaa")
var queues = BLENoiseSessionQueues()
queues.appendTypedPayload(Data([0x20, 0xAA]), transferId: "media-1", for: peerID)
queues.appendTypedPayload(Data([0x01, 0xBB]), for: peerID)
let removed = queues.removeTypedPayload(transferId: "media-1")
let removedAgain = queues.removeTypedPayload(transferId: "media-1")
#expect(removed)
#expect(!removedAgain)
#expect(queues.takeTypedPayloads(for: peerID) == [
BLEPendingTypedPayload(payload: Data([0x01, 0xBB]), transferId: nil)
])
}
}
@@ -108,6 +108,58 @@ struct BLEOutboundFragmentPlannerTests {
) == nil)
}
@Test("private media v1 accepts exactly 256 fragments and rejects 257")
func privateMediaCrossPlatformFragmentBoundary() throws {
let maxPayload = makePayload(count: 160 * 1024, seed: 0xFACE_CAFE)
func plan(payloadCount: Int) throws -> BLEOutboundFragmentPlan {
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: "0011223344556677") ?? Data(),
recipientID: Data(hexString: "8877665544332211"),
timestamp: 0x0102030405,
payload: Data(maxPayload.prefix(payloadCount)),
signature: nil,
ttl: 3,
version: 2
)
return try #require(BLEOutboundFragmentPlanner.makePlan(
for: BLEOutboundFragmentTransferRequest(
packet: packet,
pad: false,
maxChunk: nil,
directedPeer: PeerID(str: "8877665544332211"),
transferId: "boundary"
),
defaultChunkSize: TransportConfig.bleDefaultFragmentSize,
bleMaxMTU: 512,
fragmentID: Data(repeating: 0xD4, count: 8)
))
}
func firstPlan(withAtLeast target: Int) throws -> BLEOutboundFragmentPlan {
var low = 1
var high = maxPayload.count
while low < high {
let mid = low + (high - low) / 2
if try plan(payloadCount: mid).totalFragments >= target {
high = mid
} else {
low = mid + 1
}
}
return try plan(payloadCount: low)
}
let at256 = try firstPlan(withAtLeast: 256)
let at257 = try firstPlan(withAtLeast: 257)
#expect(at256.totalFragments == 256)
#expect(BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at256))
#expect(at257.totalFragments == 257)
#expect(!BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at257))
}
private func makePacket(
payload: Data,
route: [Data]? = nil,
@@ -20,6 +20,24 @@ struct BLEOutboundFragmentTransferSchedulerTests {
}
}
@Test
func explicitTransferIDReservesEncryptedPrivateFileFragments() {
var scheduler = BLEOutboundFragmentTransferScheduler()
let request = makeRequest(
type: MessageType.noiseEncrypted.rawValue,
transferId: "private-media"
)
let result = scheduler.submit(request, maxConcurrentTransfers: 1)
if case let .start(_, reservedTransferId) = result {
#expect(reservedTransferId == "private-media")
#expect(scheduler.activeCount == 1)
} else {
Issue.record("Expected encrypted private media to reserve its progress slot")
}
}
@Test
func submitQueuesFileTransferWhenSlotsAreFull() {
var scheduler = BLEOutboundFragmentTransferScheduler()
@@ -42,6 +42,37 @@ struct BLEPeerRegistryTests {
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
}
@Test("registry preserves absent versus explicit empty capabilities")
func capabilitiesPresenceIsPreserved() {
var registry = BLEPeerRegistry()
let oldPeer = PeerID(str: "1122334455667788")
let modernPeer = PeerID(str: "8877665544332211")
_ = registry.upsertVerifiedAnnounce(
peerID: oldPeer,
nickname: "old",
noisePublicKey: Data(repeating: 0x11, count: 32),
signingPublicKey: Data(repeating: 0x12, count: 32),
isConnected: true,
now: Date(),
capabilities: nil
)
_ = registry.upsertVerifiedAnnounce(
peerID: modernPeer,
nickname: "modern",
noisePublicKey: Data(repeating: 0x21, count: 32),
signingPublicKey: Data(repeating: 0x22, count: 32),
isConnected: true,
now: Date(),
capabilities: []
)
#expect(registry.capabilities(for: oldPeer).isEmpty)
#expect(!registry.capabilitiesWereExplicitlyAdvertised(for: oldPeer))
#expect(registry.capabilities(for: modernPeer).isEmpty)
#expect(registry.capabilitiesWereExplicitlyAdvertised(for: modernPeer))
}
@Test("reachability keeps recent verified offline peers only when mesh is attached")
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
let offlinePeer = PeerID(str: "1122334455667788")
@@ -237,6 +237,55 @@ struct NoiseEncryptionServiceTests {
#expect(try receiver.decrypt(ciphertext, from: alicePeerID) == Data("new session".utf8))
}
@Test("Large private-file payloads use the bounded Noise extension")
func largePrivateFileNoiseRoundTrip() throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
try establishSessions(alice: alice, bob: bob)
let content = Data("%PDF-1.7\n".utf8) + Data(repeating: 0x51, count: 96 * 1024)
let file = BitchatFilePacket(
fileName: "large-private.pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
)
let typedPayload = try #require(BLENoisePayloadFactory.privateFile(file))
#expect(typedPayload.count > NoiseSecurityConstants.maxMessageSize)
#expect(typedPayload.first == NoisePayloadType.privateFile.rawValue)
#expect(
typedPayload.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize,
"typedBytes=\(typedPayload.count) limit=\(NoiseSecurityConstants.maxPrivateFilePlaintextSize)"
)
do {
_ = try alice.encrypt(typedPayload, for: bobPeerID)
Issue.record("Ordinary Noise payload path must retain its 64 KiB ceiling")
} catch NoiseSecurityError.messageTooLarge {
// Expected: only the purpose-specific private-file API may extend it.
}
let ciphertext: Data
do {
ciphertext = try alice.encryptPrivateFilePayload(typedPayload, for: bobPeerID)
} catch {
Issue.record("Private-file encryption failed: \(error)")
return
}
let decrypted: Data
do {
decrypted = try bob.decrypt(ciphertext, from: alicePeerID)
} catch {
Issue.record("Private-file decryption failed: \(error); ciphertextBytes=\(ciphertext.count)")
return
}
#expect(ciphertext.range(of: content) == nil)
#expect(decrypted == typedPayload)
}
@Test("Encrypt without a session requests handshake and decrypt without session fails")
func handshakeRequiredAndSessionNotEstablishedErrors() throws {
let service = NoiseEncryptionService(keychain: MockKeychain())
@@ -399,6 +399,34 @@ final class SecureIdentityStateManagerTests: XCTestCase {
XCTAssertTrue(cleared)
}
func test_privateMediaCapabilityPinPersistsMonotonicallyAndPanicClearRemovesIt() async {
let keychain = MockKeychain()
let fingerprint = Data(repeating: 0x42, count: 32).sha256Fingerprint()
let manager = SecureIdentityStateManager(keychain)
XCTAssertFalse(manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint))
manager.markPrivateMediaCapable(fingerprint: fingerprint)
XCTAssertTrue(
manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint),
"pin insertion must be synchronously visible to the next downgrade decision"
)
// Re-marking is idempotent, and the encrypted cache carries the pin
// across launches.
manager.markPrivateMediaCapable(fingerprint: fingerprint)
manager.forceSave()
let reloaded = SecureIdentityStateManager(keychain)
XCTAssertTrue(reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint))
// ChatViewModel's panic path calls this same wipe after deleting
// keychain data; the in-memory pin must disappear immediately too.
reloaded.clearAllIdentityData()
let cleared = await waitUntil {
!reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint)
}
XCTAssertTrue(cleared)
}
func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async {
let keychain = FailingCacheSaveKeychain()
let manager = SecureIdentityStateManager(keychain)
@@ -49,7 +49,7 @@ struct TransferProgressManagerTests {
recorder.append("updated:\(id):\(sent):\(total)")
case .completed(let id, let total):
recorder.append("completed:\(id):\(total)")
case .cancelled:
case .cancelled, .rejected:
break
}
}
@@ -85,7 +85,7 @@ struct TransferProgressManagerTests {
recorder.append("started:\(id):\(total)")
case .cancelled(let id, let sent, let total):
recorder.append("cancelled:\(id):\(sent):\(total)")
case .updated, .completed:
case .updated, .completed, .rejected:
break
}
}
@@ -105,6 +105,28 @@ struct TransferProgressManagerTests {
#expect(manager.snapshot(id: transferID) == nil)
_ = cancellable
}
@Test("Preflight policy rejection publishes a visible failure reason")
@MainActor
func rejectBeforeStartPublishesReason() async {
let manager = TransferProgressManager()
let transferID = "transfer-visible-reject"
let recorder = EventRecorder()
let cancellable = manager.publisher.sink { event in
if case .rejected(let id, let reason) = event {
recorder.append("rejected:\(id):\(reason)")
}
}
manager.rejectBeforeStart(id: transferID, reason: "upgrade required")
let didReceive = await TestHelpers.waitUntil({
recorder.values == ["rejected:\(transferID):upgrade required"]
}, timeout: 5.0)
#expect(didReceive)
#expect(manager.snapshot(id: transferID) == nil)
_ = cancellable
}
}
private final class EventRecorder: @unchecked Sendable {
@@ -266,6 +266,9 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
verified.removeAll()
}
func markPrivateMediaCapable(fingerprint: String) {}
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { false }
func removeEphemeralSession(peerID: PeerID) {}
func setVerified(fingerprint: String, verified: Bool) {