Encrypt private media before BLE fragmentation (#1434)

Closes the last cleartext private-content path over BLE: private DM images/voice were sent as plaintext signed fileTransfer packets, TTL-relayed across the mesh, so every relay saw the full bytes. Now the complete BitchatFilePacket is encrypted as a single Noise AEAD message (inner type 0x20, matching Android) and the opaque ciphertext is fragmented. Adds an authenticated in-session capability proof (0x21 TLV: capabilities + Ed25519 key), TOFU-style downgrade pinning, a per-send consent dialog for the signed-cleartext fallback to legacy peers, and a cancellation/admission registry so cancel/delete cannot race a deferred cleartext send.

Android wire constants (0x20 / 0x21 / capability bit 8) confirmed shipping. The 256-fragment preflight cap applies only to the directed fileTransfer migration fallback; encrypted media to capable peers uses the full receiver ceiling.

Rebased over #1428/#1349: identity reads go through BLELocalIdentityStateStore; the session-bound authenticated signing-key check and the announce-path TOFU pin are kept as complementary checks. Full local suite green (1744+197 tests).
This commit is contained in:
jack
2026-07-26 12:17:22 +02:00
committed by GitHub
parent 10886428ca
commit 2d96fd99a1
55 changed files with 5428 additions and 255 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
@@ -91,11 +90,72 @@ 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
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
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 resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
completion(resolvedPrivateMediaPolicy ?? 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) {
@@ -107,6 +167,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
@@ -171,6 +281,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
@@ -301,6 +419,20 @@ struct ChatMediaTransferCoordinatorContextTests {
))
}
@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()
@@ -319,6 +451,251 @@ 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 capabilityProofTimeoutTransitionsToConsentWithoutAutomaticRawSend() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "1020304050607080")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .awaitingCapabilityProof
context.resolvedPrivateMediaPolicy = .legacyRequiresConsent
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("proof-timeout-consent-\(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)
#expect(context.privateFileSends.isEmpty)
context.resolveNextLegacyConsent(false)
#expect(context.privateFileSends.isEmpty)
}
@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)
}
}
private final class PausedImagePreparer: @unchecked Sendable {
@@ -1033,6 +1033,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
+22 -1
View File
@@ -14,6 +14,8 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
private var blockedFingerprints: Set<String> = []
private var blockedNostrPubkeys: Set<String> = []
private var socialIdentities: [String: SocialIdentity] = [:]
private var privateMediaCapableFingerprints: Set<String> = []
private var authenticatedSigningKeys: [String: Data] = [:]
init(_: KeychainManagerProtocol) {}
@@ -87,7 +89,10 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
func clearAllIdentityData() {}
func clearAllIdentityData() {
privateMediaCapableFingerprints.removeAll()
authenticatedSigningKeys.removeAll()
}
func removeEphemeralSession(peerID: PeerID) {}
@@ -101,6 +106,22 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
Set()
}
func markPrivateMediaCapable(fingerprint: String) {
privateMediaCapableFingerprints.insert(fingerprint)
}
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
privateMediaCapableFingerprints.contains(fingerprint)
}
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
authenticatedSigningKeys[fingerprint] = signingPublicKey
}
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
authenticatedSigningKeys[fingerprint]
}
// MARK: Vouching (transitive verification)
private var vouchesByVouchee: [String: [VouchRecord]] = [:]
+25
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,29 @@ 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 resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
let policy = privateMediaPolicies[peerID] ?? .encrypted
Task { @MainActor in completion(policy) }
}
func cancelTransfer(_ transferId: String) {
+179 -4
View File
@@ -542,8 +542,12 @@ struct NoiseCoverageTests {
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(
peerID:remoteKey:sessionGeneration:
)
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(
peerID:remoteKey:sessionGeneration:
)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -650,13 +654,121 @@ struct NoiseCoverageTests {
try aliceManager.initiateHandshake(with: alicePeerID)
}
try aliceManager.initiateRekey(for: alicePeerID)
let rekeyHandshake = try aliceManager.initiateRekey(for: alicePeerID)
#expect(!rekeyHandshake.isEmpty)
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
#expect(rekeyedSession !== establishedSession)
#expect(rekeyedSession.getState() == .handshaking)
}
@Test("A stale decrypt generation cannot commit across session promotion")
func staleDecryptGenerationCannotCommitAcrossPromotion() throws {
let aliceManager = NoiseSessionManager(
localStaticKey: aliceStaticKey,
keychain: keychain,
sessionFactory: { peerID, role in
BlockingDecryptNoiseSession(
peerID: peerID,
role: role,
keychain: self.keychain,
localStaticKey: self.aliceStaticKey
)
}
)
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
let oldSession = try #require(
aliceManager.getSession(for: alicePeerID) as? BlockingDecryptNoiseSession
)
let oldGeneration = try #require(aliceManager.sessionGeneration(for: alicePeerID))
// Prepare a fully authenticated responder candidate without promoting
// it yet. Its final XX message is the exact operation that replaces
// the old `sessions[peerID]` entry.
let replacementInitiator = NoiseSession(
peerID: bobPeerID,
role: .initiator,
keychain: keychain,
localStaticKey: bobStaticKey
)
let message1 = try replacementInitiator.startHandshake()
let message2 = try #require(
try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message1)
)
let message3 = try #require(try replacementInitiator.processHandshakeMessage(message2))
let ciphertext = try bobManager.encrypt(Data("old session".utf8), for: bobPeerID)
oldSession.pauseNextDecrypt()
let decryptResult = ConcurrentTestResult<(plaintext: Data, sessionGeneration: UUID)>()
var promotionResultForCleanup: ConcurrentTestResult<Data?>?
defer {
// A failed startup requirement must not strand a late thread in
// the blocking test double after the test has returned.
oldSession.resumeDecrypt()
_ = decryptResult.wait(timeout: 5)
if let promotionResultForCleanup {
_ = promotionResultForCleanup.wait(timeout: 5)
}
}
let decryptThread = Thread {
decryptResult.capture {
try aliceManager.decryptWithSessionGeneration(ciphertext, from: self.alicePeerID)
}
}
decryptThread.name = "NoiseCoverageTests.staleDecrypt.decrypt"
decryptThread.qualityOfService = .userInitiated
decryptThread.start()
try #require(oldSession.waitForDecryptStart(timeout: 5))
let promotionStarted = DispatchSemaphore(value: 0)
let promotionResult = ConcurrentTestResult<Data?>()
promotionResultForCleanup = promotionResult
let promotionThread = Thread {
promotionStarted.signal()
promotionResult.capture {
try aliceManager.handleIncomingHandshake(from: self.alicePeerID, message: message3)
}
}
promotionThread.name = "NoiseCoverageTests.staleDecrypt.promote"
promotionThread.qualityOfService = .userInitiated
promotionThread.start()
try #require(promotionStarted.wait(timeout: .now() + 5) == .success)
#expect(
promotionResult.wait(timeout: 0.05) == nil,
"Promotion must wait for the exact decrypting-session lease"
)
oldSession.resumeDecrypt()
let decrypted = try #require(decryptResult.wait(timeout: 5)).get()
_ = try #require(promotionResult.wait(timeout: 5)).get()
#expect(decrypted.plaintext == Data("old session".utf8))
#expect(decrypted.sessionGeneration == oldGeneration)
#expect(aliceManager.sessionGeneration(for: alicePeerID) != oldGeneration)
#expect(throws: NoiseEncryptionError.sessionNotEstablished) {
try aliceManager.encrypt(
Data("stale send".utf8),
for: alicePeerID,
expectedSessionGeneration: oldGeneration
)
}
var staleCommitRan = false
let staleCommit = aliceManager.withCurrentSessionGeneration(
for: alicePeerID,
expected: decrypted.sessionGeneration
) {
staleCommitRan = true
return true
}
#expect(staleCommit == nil)
#expect(!staleCommitRan)
}
@Test("Secure noise sessions enforce limits and renegotiation thresholds")
func secureNoiseSessionsEnforceLimitsAndThresholds() throws {
let initiator = SecureNoiseSession(
@@ -851,7 +963,11 @@ private final class SessionCallbackRecorder: @unchecked Sendable {
return establishedEntries.map(\.0)
}
func recordEstablished(peerID: PeerID, remoteKey: Curve25519.KeyAgreement.PublicKey) {
func recordEstablished(
peerID: PeerID,
remoteKey: Curve25519.KeyAgreement.PublicKey,
sessionGeneration _: UUID
) {
lock.lock()
establishedEntries.append((peerID, remoteKey.rawRepresentation))
lock.unlock()
@@ -873,3 +989,62 @@ private final class FailingNoiseSession: NoiseSession {
throw Error.synthetic
}
}
private final class BlockingDecryptNoiseSession: NoiseSession, @unchecked Sendable {
private let controlLock = NSLock()
private var shouldPauseNextDecrypt = false
private let decryptStarted = DispatchSemaphore(value: 0)
private let resumeDecryptSemaphore = DispatchSemaphore(value: 0)
func pauseNextDecrypt() {
controlLock.lock()
shouldPauseNextDecrypt = true
controlLock.unlock()
}
func waitForDecryptStart(timeout: TimeInterval) -> Bool {
decryptStarted.wait(timeout: .now() + timeout) == .success
}
func resumeDecrypt() {
resumeDecryptSemaphore.signal()
}
override func decrypt(_ ciphertext: Data) throws -> Data {
controlLock.lock()
let shouldPause = shouldPauseNextDecrypt
shouldPauseNextDecrypt = false
controlLock.unlock()
if shouldPause {
decryptStarted.signal()
resumeDecryptSemaphore.wait()
}
return try super.decrypt(ciphertext)
}
}
private final class ConcurrentTestResult<Value>: @unchecked Sendable {
private let lock = NSLock()
private let completed = DispatchGroup()
private var storedResult: Result<Value, Error>?
init() {
completed.enter()
}
func capture(_ operation: () throws -> Value) {
let result = Result(catching: operation)
lock.lock()
storedResult = result
lock.unlock()
completed.leave()
}
func wait(timeout: TimeInterval) -> Result<Value, Error>? {
guard completed.wait(timeout: .now() + timeout) == .success else { return nil }
lock.lock()
defer { lock.unlock() }
return storedResult
}
}
+33
View File
@@ -145,6 +145,39 @@ struct PacketsTests {
#expect(decoded.capabilities?.rawValue == 0x0180)
}
@Test
func authenticatedPeerStateUsesVersionedCanonicalTLVs() throws {
let signingKey = Data(repeating: 0xA5, count: 32)
let packet = AuthenticatedPeerStatePacket(
capabilities: [.privateMedia, .vouch],
signingPublicKey: signingKey
)
var encoded = try #require(packet.encode())
#expect(encoded.prefix(5) == Data([0x01, 0x01, 0x02, 0x20, 0x01]))
// Unknown TLVs are forward-compatible and do not alter v1 state.
encoded.append(makeTLV(type: 0x7F, value: Data([0xCA, 0xFE])))
#expect(AuthenticatedPeerStatePacket.decode(from: encoded) == packet)
}
@Test
func authenticatedPeerStateRejectsMalformedAmbiguousOrUnknownVersion() {
let key = Data(repeating: 0x44, count: 32)
let capabilities = makeTLV(type: 0x01, value: Data([0x00, 0x01]))
let signing = makeTLV(type: 0x02, value: key)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x02]) + capabilities + signing) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + signing) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + capabilities + signing) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01, 0x01, 0x00]) + signing) == nil)
// 0x0001 is non-minimal little endian; the canonical form is [0x01].
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data([0x01, 0x00])) + signing) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + makeTLV(type: 0x02, value: Data(key.dropLast()))) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + Data(signing.dropLast())) == nil)
#expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data(repeating: 0x01, count: 9)) + signing) == nil)
}
@Test
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
let unknownTLV = Data([0x7F, 0x01, 0x41])
@@ -9,6 +9,7 @@ struct BLEAnnounceHandlerTests {
var existingSigningPublicKey: Data?
var persistedSigningPublicKey: Data?
var persistedSigningKeyQueries: [PeerID] = []
var authenticatedSigningPublicKey: Data?
var signatureValid = true
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
var linkBoundToOtherPeer = false
@@ -44,6 +45,7 @@ struct BLEAnnounceHandlerTests {
recorder.persistedSigningKeyQueries.append(peerID)
return recorder.persistedSigningPublicKey
},
authenticatedSigningPublicKey: { _ in recorder.authenticatedSigningPublicKey },
verifySignature: { packet, signingPublicKey in
recorder.verifySignatureCalls.append((packet, signingPublicKey))
return recorder.signatureValid
@@ -703,6 +705,7 @@ struct BLEAnnounceHandlerTests {
return (info?.noisePublicKey, info?.signingPublicKey)
},
persistedSigningPublicKey: { _ in nil },
authenticatedSigningPublicKey: { _ in nil },
verifySignature: { packet, signingPublicKey in
victim.verifyPacketSignature(packet, publicKey: signingPublicKey)
},
@@ -817,6 +820,7 @@ struct BLEAnnounceHandlerTests {
.compactMap { $0.signingPublicKey }
.first
},
authenticatedSigningPublicKey: { _ in nil },
verifySignature: { packet, signingPublicKey in
victim.verifyPacketSignature(packet, publicKey: signingPublicKey)
},
@@ -213,6 +213,23 @@ struct BLEAnnounceHandlingPolicyTests {
#expect(decision == .verified)
}
@Test
func trustPolicyRejectsSigningKeyReplacementAfterNoiseBinding() {
let noiseKey = Data(repeating: 0xCC, count: 32)
let boundSigningKey = Data(repeating: 0x11, count: 32)
let decision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: true,
signatureValid: true,
existingNoisePublicKey: noiseKey,
announcedNoisePublicKey: noiseKey,
authenticatedSigningPublicKey: boundSigningKey,
announcedSigningPublicKey: Data(repeating: 0x22, count: 32)
)
#expect(decision == .reject(.authenticatedSigningKeyMismatch))
}
@Test
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
let directNew = BLEAnnounceResponsePolicy.plan(
@@ -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
)
@@ -529,7 +587,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,
@@ -544,7 +603,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()
@@ -10,6 +10,7 @@ struct BLENoisePacketHandlerTests {
var handshakeResult: Result<Data?, Error> = .success(nil)
var handshakeAuthenticated = false
var hasSession = false
let sessionGeneration = UUID()
var decryptResult: Result<Data, Error> = .success(Data())
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
@@ -19,6 +20,7 @@ struct BLENoisePacketHandlerTests {
var lastSeenUpdates: [PeerID] = []
var decryptCalls: [(payload: Data, peerID: PeerID)] = []
var clearedSessions: [PeerID] = []
var authenticatedPeerStates: [(peerID: PeerID, payload: Data, generation: UUID)] = []
var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = []
/// Ordered side-effect log to assert recovery sequencing.
var events: [String] = []
@@ -61,12 +63,18 @@ struct BLENoisePacketHandlerTests {
},
decrypt: { payload, peerID in
recorder.decryptCalls.append((payload, peerID))
return try recorder.decryptResult.get()
return BLENoiseDecryptionResult(
plaintext: try recorder.decryptResult.get(),
sessionGeneration: recorder.sessionGeneration
)
},
clearSession: { peerID in
recorder.clearedSessions.append(peerID)
recorder.events.append("clearSession")
},
handleAuthenticatedPeerState: { peerID, payload, generation in
recorder.authenticatedPeerStates.append((peerID, payload, generation))
},
deliverNoisePayload: { peerID, type, payload, timestamp in
recorder.deliveries.append((peerID, type, payload, timestamp))
}
@@ -244,6 +252,25 @@ struct BLENoisePacketHandlerTests {
#expect(recorder.initiatedHandshakes.isEmpty)
}
@Test
func authenticatedPeerStateIsConsumedByTransportNotDeliveredToUI() {
let recorder = Recorder()
recorder.decryptResult = .success(Data([
NoisePayloadType.authenticatedPeerState.rawValue,
0x01, 0x02, 0x03
]))
let handler = makeHandler(recorder: recorder)
let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id))
handler.handleEncrypted(packet, from: remotePeerID)
#expect(recorder.authenticatedPeerStates.count == 1)
#expect(recorder.authenticatedPeerStates.first?.peerID == remotePeerID)
#expect(recorder.authenticatedPeerStates.first?.payload == Data([0x01, 0x02, 0x03]))
#expect(recorder.authenticatedPeerStates.first?.generation == recorder.sessionGeneration)
#expect(recorder.deliveries.isEmpty)
}
@Test
func emptyDecryptedPayloadIsIgnored() {
let recorder = Recorder()
@@ -1,5 +1,6 @@
import Foundation
import Testing
import BitFoundation
@testable import bitchat
struct BLENoisePayloadFactoryTests {
@@ -31,4 +32,63 @@ 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)
}
@Test
func authenticatedPeerStateUsesPermanent0x21Type() throws {
let state = AuthenticatedPeerStatePacket(
capabilities: .privateMedia,
signingPublicKey: Data(repeating: 0x77, count: 32)
)
let encoded = try #require(BLENoisePayloadFactory.authenticatedPeerState(state))
#expect(encoded.first == 0x21)
#expect(AuthenticatedPeerStatePacket.decode(from: Data(encoded.dropFirst())) == state)
}
}
@@ -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()
@@ -123,6 +123,37 @@ struct BLEPeerRegistryTests {
#expect(registry.info(for: peerID)?.signingPublicKey == signingKey)
}
@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")
@@ -96,13 +96,22 @@ struct NoiseEncryptionServiceTests {
let recorder = AuthenticationRecorder()
#expect(alice.onPeerAuthenticated == nil)
#expect(bob.onPeerAuthenticatedWithGeneration == nil)
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
bob.onPeerAuthenticatedWithGeneration = recorder.record(
peerID:fingerprint:sessionGeneration:
)
try establishSessions(alice: alice, bob: bob)
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
#expect(authenticated)
let generationAuthenticated = await TestHelpers.waitUntil(
{ recorder.generationCount >= 1 },
timeout: 5.0
)
#expect(generationAuthenticated)
#expect(alice.hasEstablishedSession(with: bobPeerID))
#expect(bob.hasEstablishedSession(with: alicePeerID))
#expect(alice.hasSession(with: bobPeerID))
@@ -111,6 +120,7 @@ struct NoiseEncryptionServiceTests {
#expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32)
#expect(alice.getPeerFingerprint(bobPeerID) != nil)
#expect(bob.getPeerFingerprint(alicePeerID) != nil)
#expect(recorder.generation(for: alicePeerID) == bob.sessionGeneration(for: alicePeerID))
let plaintext = Data("secret payload".utf8)
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
@@ -237,6 +247,112 @@ struct NoiseEncryptionServiceTests {
#expect(try receiver.decrypt(ciphertext, from: alicePeerID) == Data("new session".utf8))
}
@Test("Automatic rekey exposes and completes its exact handshake bytes")
func automaticRekeyHandshakeIsNotStranded() 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 originalGeneration = try #require(alice.sessionGeneration(for: bobPeerID))
var leaseRan = false
let leased = alice.withCurrentSessionGeneration(
for: bobPeerID,
expected: originalGeneration
) {
leaseRan = true
return true
}
#expect(leased == true)
#expect(leaseRan)
var emittedPeerID: PeerID?
var emittedMessage: Data?
alice.onRekeyHandshakeReady = { peerID, message in
emittedPeerID = peerID
emittedMessage = message
}
try alice._test_initiateAutomaticRekey(for: bobPeerID)
#expect(emittedPeerID == bobPeerID)
#expect(alice.sessionGeneration(for: bobPeerID) == nil)
leaseRan = false
let staleLease = alice.withCurrentSessionGeneration(
for: bobPeerID,
expected: originalGeneration
) {
leaseRan = true
return true
}
#expect(staleLease == nil)
#expect(!leaseRan)
let message1 = try #require(emittedMessage)
#expect(!message1.isEmpty)
#expect(alice.hasSession(with: bobPeerID))
#expect(!alice.hasEstablishedSession(with: bobPeerID))
let message2 = try #require(
try bob.processHandshakeMessage(from: alicePeerID, message: message1)
)
let message3 = try #require(
try alice.processHandshakeMessage(from: bobPeerID, message: message2)
)
_ = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
#expect(alice.hasEstablishedSession(with: bobPeerID))
#expect(bob.hasEstablishedSession(with: alicePeerID))
#expect(alice.sessionGeneration(for: bobPeerID) != originalGeneration)
}
@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())
@@ -328,6 +444,7 @@ struct NoiseEncryptionServiceTests {
private final class AuthenticationRecorder: @unchecked Sendable {
private let lock = NSLock()
private var entries: [(PeerID, String)] = []
private var generationEntries: [(PeerID, UUID)] = []
var count: Int {
lock.lock()
@@ -335,9 +452,27 @@ private final class AuthenticationRecorder: @unchecked Sendable {
return entries.count
}
var generationCount: Int {
lock.lock()
defer { lock.unlock() }
return generationEntries.count
}
func record(peerID: PeerID, fingerprint: String) {
lock.lock()
entries.append((peerID, fingerprint))
lock.unlock()
}
func record(peerID: PeerID, fingerprint _: String, sessionGeneration: UUID) {
lock.lock()
generationEntries.append((peerID, sessionGeneration))
lock.unlock()
}
func generation(for peerID: PeerID) -> UUID? {
lock.lock()
defer { lock.unlock() }
return generationEntries.last { $0.0 == peerID }?.1
}
}
@@ -493,6 +493,59 @@ 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_noiseAuthenticatedSigningKeyBindingPersistsAndPanicClearRemovesIt() async {
let keychain = MockKeychain()
let fingerprint = Data(repeating: 0x31, count: 32).sha256Fingerprint()
let firstKey = Data(repeating: 0x41, count: 32)
let rotatedKey = Data(repeating: 0x42, count: 32)
let manager = SecureIdentityStateManager(keychain)
manager.bindAuthenticatedSigningPublicKey(firstKey, fingerprint: fingerprint)
XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), firstKey)
// A later authenticated Noise session may legitimately rotate the
// announcement signing key.
manager.bindAuthenticatedSigningPublicKey(rotatedKey, fingerprint: fingerprint)
XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey)
manager.forceSave()
let reloaded = SecureIdentityStateManager(keychain)
XCTAssertEqual(reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey)
reloaded.clearAllIdentityData()
let cleared = await waitUntil {
reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint) == nil
}
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,11 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
verified.removeAll()
}
func markPrivateMediaCapable(fingerprint: String) {}
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { false }
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {}
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? { nil }
func removeEphemeralSession(peerID: PeerID) {}
func setVerified(fingerprint: String, verified: Bool) {