mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 08:25:21 +00:00
* Gate connected-link trust on a secure session; deny forged direct announces the connected shortcut
Residual gap after the rotation-heal containment (#1401): "verified
direct" announces prove the signature but not directness — TTL is
unsigned — so a malicious connected peer can replay a victim's fresh
announce with its TTL restored. When the victim has no live link, the
replayer's link rebinds to the victim's ID and reads as "connected",
and MessageRouter's connected fast-path then trusts it outright: every
DM stalls on a Noise handshake the replayer can never complete and is
silently lost while showing "sent".
Router-level trust gate (no wire change):
- Transport gains canDeliverSecurely(to:) — BLE answers with an
established Noise session; Nostr keeps its prompt-delivery predicate;
the protocol default forwards to canDeliverPromptly for transports
without a forgeable link layer.
- MessageRouter.sendPrivate only trusts a connected link outright when
it can deliver securely; otherwise it still sends (kicking the
handshake on a genuine link) but retains a copy and hands a sealed
copy to couriers, like the reachable path. flushOutbox gets the same
gate so a flush over an insecure link resends instead of dropping the
retained copy.
Presence hardening (defense in depth): a "direct" announce arriving on
a link already bound to a different peer no longer shortcuts the
claimed peer into "connected" — only real link state does. Genuine
first-contact and direct announces are unaffected; only the ambiguous
heal path loses the forgeable shortcut.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Harden the insecure-link outbox bound; document the second-replay presence gap and the courier metadata tradeoff
Review follow-ups on the canDeliverSecurely gate:
- flushOutbox no longer counts connected-but-insecure flushes toward the
maxSendAttempts drop: the message was actually transmitted over a live
link, so a peer whose Noise handshake stalls across reconnect flapping
must not burn through the cap and lose the store-and-forward copy the
gate exists to preserve. Retention stays bounded by the 24h outbox TTL
and the per-peer FIFO cap; acks still clear it. Attempt-counting stays
for reachable-only (heuristic) sends. Regression test: >8 connected-
insecure flushes keep the retained copy, drop callback never fires.
- Document the known second-replay presence gap: linkBoundToOtherPeer
reads the binding before rebindLinkAfterVerifiedDirectAnnounce steals
the link, so once a first replay has rebound a link to an absent
victim's ID, a second replay marks the victim connected. Presence
display only — DMs stay on the retain+courier path via the router
gate. Not closed at the announce layer because the post-rebind state
is indistinguishable from a legitimate rotation/reconnect heal (a
supported, field-verified flow). Covered by a two-announce test.
- Note the accepted courier-spray metadata tradeoff at the connected-
insecure send: nearby verified peers receive a sealed copy (they learn
a DM exists, never its content), cleared on ack.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Promote a healed rotation to connected; normalize the secure-delivery probe; retain Codable properties in Periphery
Codex review follow-ups on 5017c232:
- P1: a legitimate rotation announce necessarily arrives on a link still
bound to the OLD peer ID, so the linkBoundToOtherPeer denial stored the
new identity as disconnected — and the rebind only fixed link state,
never the registry. A healed rotation then read as disconnected until
the peer happened to announce again. rebindLinkAfterVerifiedDirect-
Announce now promotes the rebound identity to connected after the
containment checks pass (registry markConnected + topology/snapshot/
peer-list refresh; the .peerConnected UI event already fired from the
announce path). This consciously moves the forged-presence residue
from second-replay to first-successful-rebind — bounded by the rebind
containment (never steals a live identity, one rebind per link per
cooldown) and still display-only: DMs stay gated on canDeliverSecurely.
Comments and the pinned tests updated; new regression test covers
rotate-on-open-link -> rebind -> isPeerConnected(new) == true.
- P2: BLEService.canDeliverSecurely probed the Noise session with the
peer ID as given, but sessions are keyed by the short wire ID — a send
keyed by the full 64-hex Noise key (favorites resolution) misread an
established session as insecure and needlessly retained + couriered
every DM until ack. Normalize with toShort() like isPeerConnected.
Test drives a real XX handshake and asserts both ID forms pass.
- Periphery: the PrekeyBundleStore.StoredBundle.noiseKey "assign-only"
flake fired again and slipped past its baselined USR (read exists in
loadFromDisk; the indexer intermittently misses it). Replace the
baseline entry with retain_codable_properties: true — deterministic,
and a truly-dead Codable field is a persisted-format change anyway.
Local periphery scan --strict: no unused code detected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
320 lines
11 KiB
Swift
320 lines
11 KiB
Swift
//
|
|
// MockTransport.swift
|
|
// bitchatTests
|
|
//
|
|
// Mock Transport implementation for unit testing ChatViewModel.
|
|
// This is free and unencumbered software released into the public domain.
|
|
//
|
|
|
|
import Foundation
|
|
import Combine
|
|
import CoreBluetooth
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
/// Mock Transport implementation for testing ChatViewModel in isolation.
|
|
/// Records all method calls and allows test code to verify interactions.
|
|
final class MockTransport: Transport {
|
|
|
|
// MARK: - Protocol Properties
|
|
|
|
weak var delegate: BitchatDelegate?
|
|
weak var eventDelegate: TransportEventDelegate?
|
|
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
|
|
|
var myPeerID: PeerID = PeerID(str: "TESTPEER")
|
|
var myNickname: String = "TestUser"
|
|
|
|
private let peerSnapshotSubject = CurrentValueSubject<[TransportPeerSnapshot], Never>([])
|
|
|
|
// MARK: - Recording Properties (for test assertions)
|
|
|
|
private(set) var sentMessages: [(content: String, mentions: [String], messageID: String?, timestamp: Date?)] = []
|
|
private(set) var sentPrivateMessages: [(content: String, peerID: PeerID, recipientNickname: String, messageID: String)] = []
|
|
private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []
|
|
private(set) var sentDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
|
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 cancelledTransfers: [String] = []
|
|
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
|
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
|
private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = []
|
|
private(set) var startServicesCallCount = 0
|
|
private(set) var stopServicesCallCount = 0
|
|
private(set) var emergencyDisconnectCallCount = 0
|
|
private(set) var broadcastAnnounceCallCount = 0
|
|
private(set) var triggeredHandshakes: [PeerID] = []
|
|
private(set) var purgedArchivePeers: [PeerID] = []
|
|
|
|
// MARK: - Configurable Mock State
|
|
|
|
var connectedPeers: Set<PeerID> = []
|
|
var reachablePeers: Set<PeerID> = []
|
|
/// Peers with an established secure session. `nil` mirrors the protocol
|
|
/// default (prompt delivery), so connected peers stay "secure" for tests
|
|
/// that never care about the distinction.
|
|
var securePeers: Set<PeerID>?
|
|
var peerNicknames: [PeerID: String] = [:]
|
|
var peerFingerprints: [PeerID: String] = [:]
|
|
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
|
private let mockKeychain = MockKeychain()
|
|
|
|
// MARK: - Transport Protocol Implementation
|
|
|
|
func currentPeerSnapshots() -> [TransportPeerSnapshot] {
|
|
peerSnapshotSubject.value
|
|
}
|
|
|
|
func setNickname(_ nickname: String) {
|
|
myNickname = nickname
|
|
}
|
|
|
|
func startServices() {
|
|
startServicesCallCount += 1
|
|
}
|
|
|
|
func stopServices() {
|
|
stopServicesCallCount += 1
|
|
}
|
|
|
|
func emergencyDisconnectAll() {
|
|
emergencyDisconnectCallCount += 1
|
|
connectedPeers.removeAll()
|
|
reachablePeers.removeAll()
|
|
}
|
|
|
|
func isPeerConnected(_ peerID: PeerID) -> Bool {
|
|
connectedPeers.contains(peerID)
|
|
}
|
|
|
|
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
|
reachablePeers.contains(peerID) || connectedPeers.contains(peerID)
|
|
}
|
|
|
|
func canDeliverSecurely(to peerID: PeerID) -> Bool {
|
|
securePeers?.contains(peerID) ?? canDeliverPromptly(to: peerID)
|
|
}
|
|
|
|
func peerNickname(peerID: PeerID) -> String? {
|
|
peerNicknames[peerID]
|
|
}
|
|
|
|
func getPeerNicknames() -> [PeerID: String] {
|
|
peerNicknames
|
|
}
|
|
|
|
func getFingerprint(for peerID: PeerID) -> String? {
|
|
peerFingerprints[peerID]
|
|
}
|
|
|
|
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState {
|
|
peerNoiseStates[peerID] ?? .none
|
|
}
|
|
|
|
func triggerHandshake(with peerID: PeerID) {
|
|
triggeredHandshakes.append(peerID)
|
|
}
|
|
|
|
func purgeArchivedPublicMessages(from peerID: PeerID) {
|
|
purgedArchivePeers.append(peerID)
|
|
}
|
|
|
|
// Noise identity wrappers backed by a mock-keychain encryption service
|
|
// (mirrors the previous `getNoiseService()` placeholder behavior: a real
|
|
// identity, but no peer sessions). Exposed so tests can assert against
|
|
// the same identity the wrappers use.
|
|
private(set) lazy var mockNoiseService = NoiseEncryptionService(keychain: mockKeychain)
|
|
|
|
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? {
|
|
mockNoiseService.getPeerPublicKeyData(peerID)
|
|
}
|
|
|
|
func noiseIdentityFingerprint() -> String {
|
|
mockNoiseService.getIdentityFingerprint()
|
|
}
|
|
|
|
func noiseStaticPublicKeyData() -> Data {
|
|
mockNoiseService.getStaticPublicKeyData()
|
|
}
|
|
|
|
func noiseSigningPublicKeyData() -> Data {
|
|
mockNoiseService.getSigningPublicKeyData()
|
|
}
|
|
|
|
func noiseSignData(_ data: Data) -> Data? {
|
|
mockNoiseService.signData(data)
|
|
}
|
|
|
|
func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool {
|
|
mockNoiseService.verifySignature(signature, for: data, publicKey: publicKey)
|
|
}
|
|
|
|
// MARK: - Messaging
|
|
|
|
func sendMessage(_ content: String, mentions: [String]) {
|
|
sentMessages.append((content, mentions, nil, nil))
|
|
}
|
|
|
|
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
|
sentMessages.append((content, mentions, messageID, timestamp))
|
|
}
|
|
|
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
|
sentPrivateMessages.append((content, peerID, recipientNickname, messageID))
|
|
}
|
|
|
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
|
sentReadReceipts.append((receipt, peerID))
|
|
}
|
|
|
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
|
sentFavoriteNotifications.append((peerID, isFavorite))
|
|
}
|
|
|
|
func sendBroadcastAnnounce() {
|
|
broadcastAnnounceCallCount += 1
|
|
}
|
|
|
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
|
sentDeliveryAcks.append((messageID, peerID))
|
|
}
|
|
|
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
|
sentBroadcastFiles.append((packet, transferId))
|
|
}
|
|
|
|
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
|
sentPrivateFiles.append((packet, peerID, transferId))
|
|
}
|
|
|
|
func cancelTransfer(_ transferId: String) {
|
|
cancelledTransfers.append(transferId)
|
|
}
|
|
|
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
|
sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA))
|
|
}
|
|
|
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
|
sentVerifyResponses.append((peerID, noiseKeyHex, nonceA))
|
|
}
|
|
|
|
var courierSendResult = true
|
|
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
|
|
sentCourierMessages.append((content, messageID, recipientNoiseKey, couriers))
|
|
return courierSendResult
|
|
}
|
|
|
|
// MARK: - Mesh Diagnostics
|
|
|
|
private(set) var sentMeshPings: [PeerID] = []
|
|
var meshPingResult: MeshPingResult?
|
|
var meshPaths: [PeerID: [PeerID]] = [:]
|
|
var meshTopologySnapshot: MeshTopologySnapshot?
|
|
|
|
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
|
|
sentMeshPings.append(peerID)
|
|
let result = meshPingResult
|
|
Task { @MainActor in completion(result) }
|
|
}
|
|
|
|
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
|
|
meshPaths[peerID]
|
|
}
|
|
|
|
func currentMeshTopology() -> MeshTopologySnapshot? {
|
|
meshTopologySnapshot
|
|
}
|
|
|
|
// MARK: - Test Helpers
|
|
|
|
/// Clears all recorded method calls for fresh assertions
|
|
func resetRecordings() {
|
|
sentMessages.removeAll()
|
|
sentPrivateMessages.removeAll()
|
|
sentReadReceipts.removeAll()
|
|
sentDeliveryAcks.removeAll()
|
|
sentFavoriteNotifications.removeAll()
|
|
sentBroadcastFiles.removeAll()
|
|
sentPrivateFiles.removeAll()
|
|
cancelledTransfers.removeAll()
|
|
sentVerifyChallenges.removeAll()
|
|
sentVerifyResponses.removeAll()
|
|
startServicesCallCount = 0
|
|
stopServicesCallCount = 0
|
|
emergencyDisconnectCallCount = 0
|
|
broadcastAnnounceCallCount = 0
|
|
triggeredHandshakes.removeAll()
|
|
}
|
|
|
|
/// Simulates a peer connecting
|
|
func simulateConnect(_ peerID: PeerID, nickname: String? = nil) {
|
|
connectedPeers.insert(peerID)
|
|
if let nickname = nickname {
|
|
peerNicknames[peerID] = nickname
|
|
}
|
|
delegate?.didConnectToPeer(peerID)
|
|
delegate?.didUpdatePeerList(Array(connectedPeers))
|
|
publishPeerSnapshots()
|
|
}
|
|
|
|
/// Simulates a peer disconnecting
|
|
func simulateDisconnect(_ peerID: PeerID) {
|
|
connectedPeers.remove(peerID)
|
|
peerNicknames.removeValue(forKey: peerID)
|
|
delegate?.didDisconnectFromPeer(peerID)
|
|
delegate?.didUpdatePeerList(Array(connectedPeers))
|
|
publishPeerSnapshots()
|
|
}
|
|
|
|
/// Simulates receiving a message
|
|
func simulateIncomingMessage(_ message: BitchatMessage) {
|
|
delegate?.didReceiveMessage(message)
|
|
}
|
|
|
|
/// Simulates receiving a public message
|
|
func simulateIncomingPublicMessage(
|
|
from peerID: PeerID,
|
|
nickname: String,
|
|
content: String,
|
|
timestamp: Date = Date(),
|
|
messageID: String? = nil
|
|
) {
|
|
delegate?.didReceivePublicMessage(
|
|
from: peerID,
|
|
nickname: nickname,
|
|
content: content,
|
|
timestamp: timestamp,
|
|
messageID: messageID
|
|
)
|
|
}
|
|
|
|
/// Simulates Bluetooth state change
|
|
func simulateBluetoothStateChange(_ state: CBManagerState) {
|
|
delegate?.didUpdateBluetoothState(state)
|
|
}
|
|
|
|
/// Updates the peer snapshot publisher
|
|
func updatePeerSnapshots(_ snapshots: [TransportPeerSnapshot]) {
|
|
peerSnapshotSubject.send(snapshots)
|
|
Task { @MainActor [weak self] in
|
|
self?.peerEventsDelegate?.didUpdatePeerSnapshots(snapshots)
|
|
}
|
|
}
|
|
|
|
private func publishPeerSnapshots() {
|
|
let now = Date()
|
|
let snapshots = connectedPeers.map { peerID in
|
|
TransportPeerSnapshot(
|
|
peerID: peerID,
|
|
nickname: peerNicknames[peerID] ?? "",
|
|
isConnected: true,
|
|
noisePublicKey: Data(hexString: peerID.bare),
|
|
lastSeen: now
|
|
)
|
|
}
|
|
updatePeerSnapshots(snapshots)
|
|
}
|
|
}
|